3.3. 使用Valgrind调试内存

Valgrind 的 Memcheck 调试工具会突出显示程序中的堆内存错误。堆内存是正在运行的程序内存的一部分,在 C 程序中通过调用 malloc() 动态分配,并通过调用 free() 释放。 Valgrind 发现的内存错误类型包括:

  • 从未初始化的内存中读取一个值。例如:

    int *ptr, x;
    ptr = malloc(sizeof(int) * 10);
    x = ptr[3];    // reading from uninitialized memory
    
  • 在未分配的内存位置读取或写入值,这通常表示数组越界错误。例如:

    ptr[11] = 100;  // writing to unallocated memory (no 11th element)
    x = ptr[11];    // reading from unallocated memory
    
  • 释放之前已经释放的内存。

    free(ptr);
    free(ptr); // freeing the same pointer a second time
    
  • 内存泄漏。 内存泄漏是一块已分配的堆内存空间,但是程序中没有任何指针变量引用它,因此无法释放它。也就是说,当程序丢失(没有任何指向)已分配的堆空间块的地址时,就会发生内存泄漏。例如:

    ptr = malloc(sizeof(int) * 10);
    ptr = malloc(sizeof(int) * 5);  // memory leak of first malloc of 10 ints
    

内存泄漏最终会导致程序耗尽堆内存空间,从而导致后续调用 malloc() 失败。其他类型的内存访问错误,例如无效的读取和写入,可能会导致程序崩溃,或者可能导致某些程序内存内容以看似神秘的方式被修改。

内存访问错误是程序中最难发现的错误之一。通常,内存访问错误不会立即导致程序执行中出现明显的错误。相反,它可能会触发稍后在执行过程中发生的错误,通常发生在程序中看似与错误来源无关的部分。有时,出现内存访问错误的程序可能在某些输入上正确运行,但在其他输入上崩溃,从而导致难以查找和修复错误原因。

使用 Valgrind 可以帮助程序员识别这些难以查找的堆内存访问错误并修复这些错误,从而节省大量的调试时间和精力。 Valgrind 还帮助程序员识别在代码测试和调试中未发现的任何潜在堆内存错误。

3.3.1. 堆内存访问错误的示例程序

作为发现和修复具有内存访问错误的程序有多么困难的示例,请考虑以下小程序 (bigfish.c)。当该程序分配超出 bigfish 数组范围的值时,该程序在第二个 for 循环中出现 写入未分配的堆内存 错误(注意:列表中包含源代码行号,并且 print_array() 函数定义未显示,但其行为如所描述的那样):

 1  #include <stdio.h>
 2  #include <stdlib.h>
 3
 4  /* print size elms of array p with name name */
 5  void print_array(int *p, int size, char *name) ;
 6
 7  int main(int argc, char *argv[]) {
 8      int *bigfish, *littlefish, i;
 9
10      // allocate space for two int arrays
11      bigfish = (int *)malloc(sizeof(int) * 10);
12      littlefish = (int *)malloc(sizeof(int) * 10);
13      if (!bigfish || !littlefish) {
14          printf("Error: malloc failed\n");
15          exit(1);
16      }
17      for (i=0; i < 10; i++) {
18          bigfish[i] = 10 + i;
19          littlefish[i] = i;
20      }
21      print_array(bigfish,10, "bigfish");
22      print_array(littlefish,10, "littlefish");
23
24      // here is a heap memory access error
25      // (write beyond bounds of allocated memory):
26      for (i=0; i < 13; i++) {
27          bigfish[i] = 66 + i;
28      }
29      printf("\nafter loop:\n");
30      print_array(bigfish,10, "bigfish");
31      print_array(littlefish,10, "littlefish");
32
33      free(bigfish);
34      free(littlefish);  // program will crash here
35      return 0;
36  }

main() 函数中,第二个 for 循环在写入超出 bigfish 数组范围的三个索引(索引 10、11 和 12)时会导致堆内存访问错误。程序不会在错误发生时崩溃(在执行第二个 for 循环时);相反,它会在稍后调用 free(littlefish) 时崩溃:

bigfish:
 10  11  12  13  14  15  16  17  18  19
littlefish:
  0   1   2   3   4   5   6   7   8   9

after loop:
bigfish:
 66  67  68  69  70  71  72  73  74  75
littlefish:
 78   1   2   3   4   5   6   7   8   9
Segmentation fault (core dumped)

在 GDB 中运行此程序表明该程序在调用 free(littlefish) 时因段错误而崩溃。此时崩溃可能会让程序员怀疑访问 littlefish 数组时存在错误。然而,错误的原因是写入bigfish 数组,与程序访问 littlefish 数组的错误无关。

程序崩溃的最可能原因是 for 循环超出了 bigfish 数组的范围,并覆盖了 bigfish 最后一个分配元素和 littlefish 第一个分配元素之间的堆内存位置。 malloc() 使用两者之间的堆内存位置(以及 littlefish 的第一个元素之前)来存储有关为 littlefish 数组分配的堆内存的元数据。在内部,free() 函数使用此元数据来确定要释放多少堆内存。对 bigfish 索引 1011 的修改会覆盖这些元数据值,导致程序在调用free(littlefish)时崩溃。但我们注意到,并非所有 malloc() 函数的实现都使用此策略。

由于该程序包含在 bigfish 内存访问错误后打印出 littlefish 的代码,因此错误的原因对于程序员来说可能更明显:第二个 for 循环以某种方式修改了 littlefish 数组的内容(其下标为0的元素值在循环后 神秘地0 更改为 78 )。然而,即使在这个非常小的程序中,也可能很难找到真正的错误:如果程序在第二个出现内存访问错误的 for 循环之后没有打印出 littlefish ,或者 for 循环上限是 12 而不是 13 ,则程序变量值不会出现明显的神秘变化,无法帮助程序员发现程序访问 bigfish 数组的方式存在错误。

在较大的程序中,这种类型的内存访问错误可能位于程序代码中与崩溃部分截然不同的部分。用于访问已损坏的堆内存的变量与用于错误地覆盖同一内存的变量之间也可能没有逻辑关联;相反,它们唯一的关联是它们碰巧引用了在堆中紧密分配的内存地址。请注意,这种情况在程序的不同运行过程中可能会有所不同,并且这种行为通常对程序员是隐藏的。同样,有时错误的内存访问不会对程序的运行产生明显影响,从而使这些错误难以发现。每当一个程序对于某些输入似乎运行良好,但在其他输入上崩溃时,这就是程序中内存访问错误的迹象。

像 Valgrind 这样的工具可以通过快速向程序员指出代码中堆内存访问错误的来源和类型来节省数天的调试时间。在前面的程序中,Valgrind 描绘了错误发生的点(当程序访问超出 bigfish 数组范围的元素时)。 Valgrind 错误消息包括错误类型、程序中发生错误的位置以及程序中错误内存访问附近的堆内存的分配位置。例如,以下是程序执行第 27 行时 Valgrind 将显示的信息(省略了实际 Valgrind 错误消息中的一些详细信息):

Invalid write
 at main (bigfish.c:27)
 Address is 0 bytes after a block of size 40 alloc'd
   by main (bigfish.c:11)

此 Valgrind 错误消息表明程序正在第 27 行写入无效(未分配)堆内存,并且该无效内存紧接在第 11 行分配的内存块之后,表明循环正在访问 bigfish 指向的堆空间中已分配内存范围之外的某些元素。解决此错误的一个潜在方法是增加传递给 malloc() 的字节数或更改第二个 for 循环边界,以避免写入超出分配的堆内存空间的边界。

除了查找堆内存中的内存访问错误之外,Valgrind 还可以查找栈内存访问的一些错误,例如使用未初始化的局部变量或尝试访问超出当前栈边界的栈内存位置。但是,Valgrind 不会以与堆内存相同的粒度检测栈内存访问错误,并且不会检测全局数据内存的内存访问错误。

程序可能存在 Valgrind 无法找到的栈和全局内存的内存访问错误。但是,这些错误会导致错误的程序行为或程序崩溃,这与堆内存访问错误可能发生的行为类似。例如,覆盖超出栈上静态声明数组范围的内存位置可能会导致 神秘 地更改其他局部变量的值,或者可能会覆盖保存在栈上的用于从函数调用返回的状态,从而导致函数返回时崩溃。使用 Valgrind 处理堆内存错误的经验可以帮助程序员识别并修复访问栈和全局内存时的类似错误。

3.3.2. 怎样使用Memcheck

我们在示例程序 valgrindbadprog.c 上说明了 Valgrind Memcheck 内存分析工具的一些主要功能,该程序包含几个不良内存访问错误(代码中的注释描述了错误类型)。 Valgrind 默认运行 Memcheck 工具;我们在后面的代码片段中依赖于这种默认行为。您可以使用 --tool=memcheck 选项显式指定 Memcheck 工具。在后面的部分中,我们将通过调用 --tool 选项来调用其他 Valgrind 分析工具。

要运行 Memcheck,请首先使用 -g 标志编译 valgrindbadprog.c 程序,以将调试信息添加到可执行文件(例如 a.out )。然后,使用 valgrind 运行可执行文件。请注意,对于非交互式程序,将 Valgrind 的输出重定向到文件以便在程序退出后查看可能会有所帮助:

$ gcc -g valgrindbadprog.c
$ valgrind -v ./a.out

# re-direct valgrind (and a.out) output to file 'output.txt'
$ valgrind -v ./a.out >& output.txt

# view program and valgrind output saved to out file
$ vim output.txt

Valgrind 的 Memcheck 工具会打印出程序执行期间发生的内存访问错误和警告。在程序执行结束时,Memcheck 还会打印出有关程序中任何内存泄漏的摘要。尽管修复内存泄漏很重要,但其他类型的内存访问错误对于程序的正确性更为重要。因此,除非内存泄漏导致程序耗尽堆内存空间并崩溃,否则程序员在考虑内存泄漏之前应首先关注修复这些其他类型的内存访问错误。要查看各个内存泄漏的详细信息,请使用 --leak-check=yes 选项。

第一次使用 Valgrind 时,它的输出可能看起来有点难以解析。但是,输出都遵循相同的基本格式,一旦您了解了这种格式,就可以更轻松地理解 Valgrind 显示的有关堆内存访问错误和警告的信息。以下是运行 valgrindbadprog.c 程序时出现的 Valgrind 错误示例:

==31059== Invalid write of size 1
==31059==    at 0x4006C5: foo (valgrindbadprog.c:29)
==31059==    by 0x40079A: main (valgrindbadprog.c:56)
==31059==  Address 0x52045c5 is 0 bytes after a block of size 5 alloc'd
==31059==    at 0x4C2DB8F: malloc (in /usr/lib/valgrind/...)
==31059==    by 0x400660: foo (valgrindbadprog.c:18)
==31059==    by 0x40079A: main (valgrindbadprog.c:56)

Valgrind 输出的每一行都以进程的 ID (PID) 号为前缀(本例中为 31059):

==31059==

大多数 Valgrind 错误和警告具有以下格式:

  1. 错误或警告的类型
  2. 错误发生的位置(错误发生时程序执行栈跟踪。)
  3. 错误周围的堆内存被分配的位置(通常是与错误相关的内存分配。)

在前面的示例错误中,第一行显示对内存的无效写入(写入堆中未分配的内存 — 一个非常严重的错误!):

==31059== Invalid write of size 1

接下来的几行显示发生错误的堆栈跟踪。这些表明函数 foo() 的第 29 行发生了无效写入,该函数是从第 56 行的函数 main() 调用的:

==31059== Invalid write of size 1
==31059==    at 0x4006C5: foo (valgrindbadprog.c:29)
==31059==    by 0x40079A: main (valgrindbadprog.c:56)

其余行指示无效写入附近的堆空间在程序中的分配位置。 Valgrind 输出的这一部分表明,无效写入紧接在 5 字节堆内存空间块之后( 0 个字节之后 ),该块是通过在函数 foo() 第 18 行调用 malloc() 分配的,由第 56 行 main() 调用:

==31059==  Address 0x52045c5 is 0 bytes after a block of size 5 alloc'd
==31059==    at 0x4C2DB8F: malloc (in /usr/lib/valgrind/...)
==31059==    by 0x400660: foo (valgrindbadprog.c:18)
==31059==    by 0x40079A: main (valgrindbadprog.c:56)

此错误中的信息表明程序中存在未分配的堆内存写入错误,并将用户引导至程序中发生错误的特定部分(第 29 行)以及分配错误周围的内存的位置(第 18 行)。通过查看程序中的这些点,程序员可以了解错误的原因和修复方法:

 18   c = (char *)malloc(sizeof(char) * 5);
 ...
 22   strcpy(c, "cccc");
 ...
 28   for (i = 0; i <= 5; i++) {
 29       c[i] = str[i];
 30   }

原因是 for 循环执行了太多次,访问了 c[5] ,超出了数组 c 的末尾。解决方法是更改​​第 29 行的循环边界或在第 18 行分配更大的数组。

如果检查 Valgrind 错误周围的代码不足以让程序员理解或修复错误,那么使用 GDB 可能会有所帮助。在代码中与 Valgrind 错误相关的点周围设置断点可以帮助程序员评估程序的运行时状态并了解 Valgrind 错误的原因。例如,通过在第 29 行放置断点并打印 istr 的值,当 i 为 5 时,程序员可以看到数组越界错误。在这种情况下,使用 Valgrind 和 GDB 的组合可以帮助程序员确定如何修复 Valgrind 发现的内存访问错误。

虽然本章重点介绍了 Valgrind 的默认 Memcheck 工具,但我们将在本书后面介绍 Valgrind 的一些其他功能,包括 Cachegrind 缓存分析工具(第 11 章)Callgrind 代码分析工具(第 11 章) 12),以及Massif 内存分析工具(第 12 章)。有关使用 Valgrind 的更多信息,请参阅 Valgrind 主页 及其在线手册